Skip to content

Process sandbox: kernel-enforced filesystem containment for shell/exec#188

Merged
jamiepine merged 5 commits intomainfrom
sandboxing
Feb 24, 2026
Merged

Process sandbox: kernel-enforced filesystem containment for shell/exec#188
jamiepine merged 5 commits intomainfrom
sandboxing

Conversation

@jamiepine
Copy link
Member

@jamiepine jamiepine commented Feb 24, 2026

Summary

Replaces ~215 lines of string-based command filtering in shell.rs and exec.rs with OS-level filesystem sandboxing via bubblewrap (Linux) and sandbox-exec (macOS).

Why

The current security model uses string inspection to block dangerous commands before execution — pattern matching against instance directory paths, sensitive filenames, secret env vars, subshell syntax, eval/exec builtins, interpreter one-liners, and /proc paths. This is whack-a-mole against an adversary that can try unlimited variations.

The incident from worker 022aa8f2 demonstrates the problem: the file tool correctly blocked a read outside workspace, so the worker used shell + cp instead and succeeded. Every new blocked pattern just teaches the LLM to find the next bypass. String filtering fundamentally can't win against an adversary with unrestricted creativity and unlimited retries.

The fix is to stop playing at the string level and enforce containment at the kernel level. A bwrap mount namespace makes the entire filesystem read-only except the workspace — there's no command the LLM can construct that writes outside the boundary, because the OS won't allow it.

What changed

  • New src/sandbox.rsSandbox struct with backend detection (bwrap preflight probe on Linux, sandbox-exec existence check on macOS), command wrapping, and SBPL profile generation
  • shell.rs — Removed check_command(), SENSITIVE_FILES, SECRET_ENV_VARS, contains_shell_builtin(), instance_dir field. Commands now go through sandbox.wrap()
  • exec.rs — Removed check_args(), instance_dir, secret env var blocking. Kept DANGEROUS_ENV_VARS (library injection prevention — bwrap doesn't stop env var poisoning)
  • send_file.rs — Added workspace path validation (was missing entirely — any readable file on disk could be exfiltrated)
  • Config — New [agents.sandbox] section with mode and writable_paths, wired through AgentConfigResolvedAgentConfigRuntimeConfig with hot-reload
  • WiringSandbox created per agent at startup, stored on AgentDeps, passed through tool server factory functions. ApiState tracks sandboxes for dynamic agent creation/deletion
  • Dockerfile — Added bubblewrap to slim stage (~50KB)
  • Docs — Updated config reference, tools, workers, and docker pages

What this doesn't cover

  • Network isolation (workers can still curl data out)
  • Resource limits (CPU/memory — covered by existing timeouts and Fly VM limits)
  • Landlock as a secondary layer (future defense-in-depth option)

Note

This PR introduces kernel-enforced filesystem containment via bubblewrap and sandbox-exec, eliminating 215+ lines of string-based filtering. New src/sandbox.rs module wraps shell/exec commands in mount namespaces where the filesystem is read-only except the workspace. Configuration via [agents.sandbox] with mode and writable_paths options. Workspace path validation added to send_file.rs to prevent file exfiltration.

Written by Tembo for commit 4bbdd63.

…andbox-exec. Update configuration to support per-agent sandbox settings, including writable paths. Enhance documentation to reflect new sandbox features and usage. Modify tools to utilize the sandbox for executing commands, ensuring filesystem containment and security.
@jamiepine jamiepine marked this pull request as ready for review February 24, 2026 03:50

/// Validate that a path falls within the workspace boundary.
fn validate_workspace_path(&self, path: &std::path::Path) -> Result<PathBuf, SendFileError> {
let Some(ref workspace) = self.workspace else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This currently fails open when workspace is unset, which makes it easy for any call site that still uses SendFileTool::new() to exfiltrate arbitrary files.

If the goal is to actually enforce the boundary, I'd strongly consider failing closed here and updating call sites to always use with_workspace.

Suggested change
let Some(ref workspace) = self.workspace else {
let Some(ref workspace) = self.workspace else {
return Err(SendFileError("send_file workspace is not configured".into()));
};

@coderabbitai
Copy link

coderabbitai bot commented Feb 24, 2026

Walkthrough

Adds a per-agent, kernel-backed subprocess sandbox (bubblewrap on Linux, sandbox-exec on macOS, fallback passthrough). Introduces src/sandbox.rs, threads sandbox config through agent/runtime state, initializes and tracks sandboxes per agent, and updates tools to execute inside the sandbox. Docs and Dockerfile updated.

Changes

Cohort / File(s) Summary
Sandbox Core
src/sandbox.rs
New module: SandboxConfig, SandboxMode, SandboxBackend and Sandbox with backend detection (bubblewrap/sandbox-exec/none), command-wrapping APIs, path canonicalization, and SBPL profile generation.
Config & Public API
src/config.rs, src/lib.rs
Added sandbox fields to AgentConfig/ResolvedAgentConfig/TomlAgentConfig/RuntimeConfig; pub mod sandbox exposed; AgentDeps now includes sandbox: Arc<Sandbox>.
Agent & State Wiring
src/main.rs, src/api/agents.rs, src/api/state.rs, src/agent/worker.rs
Per-agent Sandbox instances created at init, stored in ApiState.sandboxes (ArcSwap<HashMap<...>>), propagated via AgentDeps, and used in tool server creation/warmup flows.
Tools & Tool Servers
src/tools.rs, src/tools/shell.rs, src/tools/exec.rs, src/tools/send_file.rs
Tool server constructors now accept Arc<Sandbox> (removed instance_dir). ShellTool/ExecTool use sandbox.wrap(...) for execution; SendFileTool enforces workspace-boundary validation.
Tests / Bootstrap
tests/bulletin.rs, tests/context_dump.rs
Updated test bootstrap deps to construct and include Sandbox in AgentDeps and to pass sandbox into subsequent tool server calls.
Design & Docs
docs/design-docs/sandbox.md, docs/content/docs/(configuration)/config.mdx, docs/content/docs/(features)/tools.mdx, docs/content/docs/(features)/workers.mdx, docs/content/docs/(getting-started)/docker.mdx
Design doc and user docs added/expanded: sandbox config block, behavior details for shell/exec/file tools, leak detection notes, and Docker docs updated to include bubblewrap.
Dockerfile
Dockerfile
Adds bubblewrap to apt-get install in slim runtime image.

Sequence Diagram

sequenceDiagram
    participant Init as Agent Init
    participant Sandbox as Sandbox (manager)
    participant Backend as Platform Backend
    participant ToolSrv as Tool Server / Tool
    participant Cmd as Subprocess

    Init->>Sandbox: Sandbox::new(config, workspace, instance_dir, data_dir)
    Sandbox->>Backend: detect_backend()
    alt Linux
        Backend-->>Sandbox: Bubblewrap available
    else macOS
        Backend-->>Sandbox: sandbox-exec available
    else Fallback
        Backend-->>Sandbox: None (passthrough)
    end
    Sandbox-->>Init: Arc<Sandbox> (mode, backend, writable_paths)

    Init->>ToolSrv: create_tool_server(workspace, sandbox)
    ToolSrv-->>Sandbox: wrap(program, args, working_dir)
    Sandbox->>Backend: wrap_bubblewrap / wrap_sandbox_exec / passthrough
    Backend-->>Sandbox: tokio::Command (wrapped)
    Sandbox-->>ToolSrv: Command
    ToolSrv->>Cmd: execute()
    Cmd-->>ToolSrv: output/result
    ToolSrv-->>Init: result
Loading

Estimated Code Review Effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 I dug a burrow safe and sound,

bubblewrap walls all around,
Processes hop in a cozy nest,
Paths kept tidy, secrets rest,
I nibble logs — sandboxed, blessed.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: introducing kernel-enforced filesystem containment via process sandboxing for shell/exec tools.
Description check ✅ Passed The description provides comprehensive context about the changeset, including the security motivation, implementation details across multiple files, configuration changes, and explicit scope limitations.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch sandboxing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

- send_file: require workspace at construction, no fail-open path
- sandbox: use split_paths/join_paths for PATH (portable)
- sandbox: canonicalize workspace/writable_paths at init, drop missing entries
- sandbox: add explicit data_dir deny to SBPL profile (macOS parity with bwrap)
- config: remove dead sandbox hot-reload (Sandbox is built once at startup)
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/tools/exec.rs (1)

163-182: ⚠️ Potential issue | 🟡 Minor

Resolve relative working_dir against the workspace before canonicalization.

The current code calls canonicalize() directly on the user-provided string, which resolves relative paths against the process CWD, not the workspace root. This rejects valid workspace-relative paths (e.g., working_dir="subdir/") if the process is running from a different directory. Join relative paths to the workspace first, then canonicalize and validate:

let working_dir = if let Some(ref dir) = args.working_dir {
    let path = if std::path::Path::new(dir).is_relative() {
        self.workspace.join(dir)
    } else {
        std::path::Path::new(dir).to_path_buf()
    };
    let canonical = path.canonicalize().unwrap_or_else(|_| path);
    // ... rest of validation
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/exec.rs` around lines 163 - 182, The code currently canonicalizes
args.working_dir directly (using canonicalize) which resolves relative paths
against the process CWD; change the logic in the working_dir resolution so that
if args.working_dir is a relative path you first resolve it against
self.workspace (e.g., self.workspace.join(dir)), then call canonicalize (falling
back to the joined path on error), and then perform the starts_with check
against self.workspace.canonicalize() before returning or raising the ExecError;
update references to args.working_dir, self.workspace, canonicalize, and
ExecError in that block accordingly.
src/tools/shell.rs (1)

106-125: ⚠️ Potential issue | 🟡 Minor

Resolve relative working_dir against the workspace before canonicalization.

Both shell and exec tools canonicalize the working_dir directly against the process CWD instead of normalizing relative paths to the workspace first. A relative path like "subdir" will resolve relative to the process's current working directory, not the workspace root. While the starts_with() check prevents outright escapes, this diverges from documented behavior: relative paths should be resolved against the workspace (or the provided working_dir itself, if applicable).

Normalize relative paths to the workspace before canonicalization:

let path = if dir.starts_with('/') {
    PathBuf::from(dir)
} else {
    self.workspace.join(dir)
};
let canonical = path.canonicalize()...

Apply the same fix to both src/tools/shell.rs and src/tools/exec.rs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/shell.rs` around lines 106 - 125, The current logic canonicalizes
args.working_dir directly against the process CWD, so change it to resolve
relative paths against the workspace before canonicalization: when
args.working_dir is Some(dir), build path = if Path::new(dir).is_absolute() {
PathBuf::from(dir) } else { self.workspace.join(dir) }, then call canonicalize()
on that path and compare against workspace_canonical (the canonicalized
self.workspace) using starts_with as before; update the same pattern in the
corresponding code in exec (apply to src/tools/exec.rs) so both shell and exec
normalize relative working_dir against self.workspace before canonicalization
and the starts_with check.
src/api/agents.rs (1)

363-393: ⚠️ Potential issue | 🟠 Major

Warmup can report accepted agents even when sandbox is missing.
Line 363 loads sandboxes, but resolve_warmup_agent_ids doesn’t require them; Line 391 continues if missing, so the response can include agents that never warm up (and single-agent requests won’t error). Add sandbox IDs to the dependency check or filter/return an error for single-target requests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/agents.rs` around lines 363 - 393, The warmup flow calls
resolve_warmup_agent_ids and returns accepted_agents without verifying
sandboxes, so agents lacking a sandbox (loaded into sandboxes via
state.sandboxes.load()) get included but are skipped later when you lookup
sandboxes, causing silent skips or missing errors for single-agent requests;
update the dependency-check to include sandbox IDs (add
sandboxes.keys().cloned() into the HashSet checks passed to
resolve_warmup_agent_ids) or, if request.agent_id.is_some(), validate that each
accepted_agent has a corresponding sandbox and return an error when missing
(i.e., perform an existence check on sandboxes for each agent_id before building
the response) so accepted_agents never contains IDs without sandboxes in
resolve_warmup_agent_ids / the loop that uses runtime_config, memory_search,
mcp_manager, sqlite_pool, and sandbox.
♻️ Duplicate comments (5)
src/tools/send_file.rs (1)

40-63: Fail-open when workspace is None permits reading arbitrary files.

This was raised in a prior review. When no workspace is configured, validate_workspace_path returns Ok(path) for any input, allowing file exfiltration. Consider failing closed (returning an error) when workspace is unset, and updating all call sites to use with_workspace.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/send_file.rs` around lines 40 - 63, validate_workspace_path
currently allows any path when self.workspace is None which enables file
exfiltration; change it to return a SendFileError when workspace is unset
(fail-closed) instead of returning Ok(path), and update all call sites to call
with_workspace (or otherwise ensure a workspace is provided) before invoking
validate_workspace_path so callers can no longer bypass workspace checks; refer
to the validate_workspace_path function and the SendFileError type and adjust
callers that currently rely on the None behavior to use with_workspace to supply
a workspace.
src/sandbox.rs (3)

120-123: PATH concatenation uses hardcoded : separator and std::env::var (lossy on non-UTF-8).

This was flagged in a prior review. Using std::env::split_paths/std::env::join_paths with var_os would be more robust.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox.rs` around lines 120 - 123, The PATH construction currently
builds path_env with std::env::var and a hardcoded ":" and so can fail for
non-UTF-8 PATHs and non-Unix separators; change it to use std::env::var_os +
std::env::split_paths and std::env::join_paths so you append self.tools_bin (as
a PathBuf/OsString) onto the existing PATH entries portably; update the code
that sets path_env (the variable created where self.tools_bin.display() is used)
to collect the existing var_os, push the tools_bin path, then join_paths to
produce an OsString/Path that you can pass into the child environment.

255-259: Past review: SBPL profile does not protect data_dir as read-only.

If workspace overlaps with data_dir on macOS, the profile currently allows writes to the data directory. This was flagged in a prior review.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox.rs` around lines 255 - 259, When building the SBPL profile in
sandbox.rs where profile.push_str adds the workspace writable rule, ensure the
data_dir is explicitly protected by denying writes to it before allowing
workspace writes: reference the profile construction code that uses workspace
and data_dir variables and add a deny file-write* (subpath "<data_dir>") entry
(or otherwise constrain the workspace allow to exclude data_dir) so that macOS
profiles cannot grant write access to data_dir when workspace overlaps it.

163-168: Past review: canonicalize workspace/writable_paths before passing to bwrap.

Symlinked paths can unintentionally expand the writable boundary, and missing paths will cause spawn failures. This was flagged previously.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox.rs` around lines 163 - 168, self.workspace and entries in
self.writable_paths are passed raw to bwrap, which lets symlinks escape the
intended sandbox and fails on missing paths; canonicalize the paths before
adding bind args: call std::fs::canonicalize on self.workspace and each writable
in the code that builds cmd (the block that uses
cmd.arg("--bind").arg(&self.workspace)... and the for writable in
&self.writable_paths loop), handle errors by either creating missing directories
or returning a descriptive error, dedupe canonical paths (e.g. with a HashSet)
to avoid duplicate binds, and use the canonicalized path values for both source
and destination when invoking cmd.arg("--bind").
src/config.rs (1)

3762-3762: ⚠️ Potential issue | 🟠 Major

Hot-reload updates config but not the active Sandbox instance.
Line 3762 swaps the SandboxConfig, but the per-agent Sandbox used by tools is constructed elsewhere at startup and isn’t rebuilt. If you expect live changes to take effect, rebuild/swap the sandbox (and downstream tool servers) on reload, or make sandbox config explicitly non-reloadable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.rs` at line 3762, The reload currently only swaps the
SandboxConfig via self.sandbox.store(Arc::new(resolved.sandbox)); but does not
rebuild the runtime per-agent Sandbox instances or notify tool servers; update
the reload path to (1) construct new Sandbox instances from the new
SandboxConfig (use the same constructor(s) used at startup—locate where Sandbox
is created/initialized) and atomically swap those active Sandbox references for
each agent, and (2) propagate the new Sandbox to downstream tool servers (call
their reload/update methods or restart them) so they use the new instance;
alternatively, if dynamic swapping is not desired, make SandboxConfig
non-reloadable by reverting the store call and emitting a clear log/warning that
Sandbox changes require restart.
🧹 Nitpick comments (1)
docs/content/docs/(features)/workers.mdx (1)

146-158: The identity-file guard implementation is correct; consider clarifying that sandbox isolation covers filesystem containment only.

The guard at src/tools/file.rs:206–215 correctly blocks writes to SOUL.md, IDENTITY.md, and USER.md, with an error message directing users to the identity management API. The workspace boundary validation in resolve_path() (lines 26–55) is also in place. The documentation is accurate.

For clarity: consider a brief note that sandboxing covers only filesystem containment (read-only host FS, writable workspace) and doesn't include network or CPU isolation, so readers don't infer full system isolation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/`(features)/workers.mdx around lines 146 - 158, The docs
state sandboxing behavior but can be misread as full system isolation; update
the "Sandbox and Protected Paths" section to explicitly state that sandbox
isolation covers filesystem containment only (read-only host FS, writable
workspace, sandbox-specific writable_paths) and does NOT provide network, CPU,
or other resource isolation; reference the file-tool guard (blocks SOUL.md,
IDENTITY.md, USER.md), resolve_path(), and exec tool environment-variable checks
so readers know which protections exist.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/content/docs/`(configuration)/config.mdx:
- Around line 524-551: The docs incorrectly state that setting
SPACEBOT_DEPLOYMENT=hosted forces agents.sandbox.mode = "enabled" in-app; update
the `[agents.sandbox]` section to either remove that guarantee or explicitly
state that sandbox enforcement is performed by external platform infrastructure
(e.g., "platform boot script") rather than by application logic. Reference the
config key `agents.sandbox`, the env var `SPACEBOT_DEPLOYMENT=hosted`, and the
design note about the platform boot script so readers know this is a
platform-level enforcement and not enforced by any in-app function or config
migration.

In `@docs/design-docs/sandbox.md`:
- Around line 187-201: The fenced code blocks in the sandbox documentation (the
bwrap command block and the other blocks at the noted ranges) are missing
language identifiers; update each triple-backtick fence to include an
appropriate language tag (e.g., change ``` to ```sh for shell snippets like the
bwrap block, or use ```text/```sh/```scheme as appropriate for the other blocks)
so markdownlint MD040 is satisfied, ensuring the fences around the bwrap snippet
and the blocks at the other reported ranges are consistently tagged.
- Around line 19-35: Summary: Markdown table code spans containing pipe
characters (e.g., `env |` and `env >`) need escaping to satisfy markdownlint
rules MD056/MD038. Fix: in the sandbox.md table replace any inline code spans
that include a pipe with an escaped pipe (e.g., `env \|` and `env \>`) or use
the HTML entity (e.g., `env &#124;`) so the table remains four columns; update
any similar entries (like `env |`, `env >`, or other code spans containing `|`)
throughout the table rows describing SECRET_ENV_VARS, broad env dumps, and
related entries to ensure markdownlint no longer flags extra columns.

In `@src/sandbox.rs`:
- Around line 236-292: generate_sbpl_profile interpolates filesystem paths
directly into SBPL strings, allowing path injection if a path contains quotes or
other special characters; fix by creating a small sanitizer (e.g., escape
double-quotes and backslashes or validate/reject characters) and use it on
values returned by canonicalize_or_self for workspace, each entry in
self.writable_paths, and the tmp path before calling format! so that strings
inserted into the profile are SBPL-safe; reference generate_sbpl_profile,
canonicalize_or_self, and self.writable_paths when updating the code.
- Around line 322-343: The preflight bwrap check currently runs "/usr/bin/true"
which isn't portable; replace that inner command with a POSIX-guaranteed shell
invocation by calling bwrap with the final args "--", "/bin/sh", "-c", "exit 0"
so the Command created for the proc_check uses "/bin/sh -c 'exit 0'" instead of
"/usr/bin/true"; update the args array used to build proc_check (the
Command::new("bwrap") invocation and its args) so proc_supported correctly
reflects bwrap/--proc support on minimal systems.

In `@src/tools/exec.rs`:
- Around line 202-207: The variable name arg_refs is abbreviated; rename it to a
full descriptive name like argument_refs across this function so it follows
naming guidelines: change the declaration let arg_refs: Vec<&str> =
args.args.iter().map(|s| s.as_str()).collect(); to let argument_refs: Vec<&str>
= ... and update its usage in the call to self.sandbox.wrap(&args.program,
&arg_refs, &working_dir) (and any other references) to use &argument_refs
instead; ensure the new name is used consistently and the type/signature remains
unchanged.

---

Outside diff comments:
In `@src/api/agents.rs`:
- Around line 363-393: The warmup flow calls resolve_warmup_agent_ids and
returns accepted_agents without verifying sandboxes, so agents lacking a sandbox
(loaded into sandboxes via state.sandboxes.load()) get included but are skipped
later when you lookup sandboxes, causing silent skips or missing errors for
single-agent requests; update the dependency-check to include sandbox IDs (add
sandboxes.keys().cloned() into the HashSet checks passed to
resolve_warmup_agent_ids) or, if request.agent_id.is_some(), validate that each
accepted_agent has a corresponding sandbox and return an error when missing
(i.e., perform an existence check on sandboxes for each agent_id before building
the response) so accepted_agents never contains IDs without sandboxes in
resolve_warmup_agent_ids / the loop that uses runtime_config, memory_search,
mcp_manager, sqlite_pool, and sandbox.

In `@src/tools/exec.rs`:
- Around line 163-182: The code currently canonicalizes args.working_dir
directly (using canonicalize) which resolves relative paths against the process
CWD; change the logic in the working_dir resolution so that if args.working_dir
is a relative path you first resolve it against self.workspace (e.g.,
self.workspace.join(dir)), then call canonicalize (falling back to the joined
path on error), and then perform the starts_with check against
self.workspace.canonicalize() before returning or raising the ExecError; update
references to args.working_dir, self.workspace, canonicalize, and ExecError in
that block accordingly.

In `@src/tools/shell.rs`:
- Around line 106-125: The current logic canonicalizes args.working_dir directly
against the process CWD, so change it to resolve relative paths against the
workspace before canonicalization: when args.working_dir is Some(dir), build
path = if Path::new(dir).is_absolute() { PathBuf::from(dir) } else {
self.workspace.join(dir) }, then call canonicalize() on that path and compare
against workspace_canonical (the canonicalized self.workspace) using starts_with
as before; update the same pattern in the corresponding code in exec (apply to
src/tools/exec.rs) so both shell and exec normalize relative working_dir against
self.workspace before canonicalization and the starts_with check.

---

Duplicate comments:
In `@src/config.rs`:
- Line 3762: The reload currently only swaps the SandboxConfig via
self.sandbox.store(Arc::new(resolved.sandbox)); but does not rebuild the runtime
per-agent Sandbox instances or notify tool servers; update the reload path to
(1) construct new Sandbox instances from the new SandboxConfig (use the same
constructor(s) used at startup—locate where Sandbox is created/initialized) and
atomically swap those active Sandbox references for each agent, and (2)
propagate the new Sandbox to downstream tool servers (call their reload/update
methods or restart them) so they use the new instance; alternatively, if dynamic
swapping is not desired, make SandboxConfig non-reloadable by reverting the
store call and emitting a clear log/warning that Sandbox changes require
restart.

In `@src/sandbox.rs`:
- Around line 120-123: The PATH construction currently builds path_env with
std::env::var and a hardcoded ":" and so can fail for non-UTF-8 PATHs and
non-Unix separators; change it to use std::env::var_os + std::env::split_paths
and std::env::join_paths so you append self.tools_bin (as a PathBuf/OsString)
onto the existing PATH entries portably; update the code that sets path_env (the
variable created where self.tools_bin.display() is used) to collect the existing
var_os, push the tools_bin path, then join_paths to produce an OsString/Path
that you can pass into the child environment.
- Around line 255-259: When building the SBPL profile in sandbox.rs where
profile.push_str adds the workspace writable rule, ensure the data_dir is
explicitly protected by denying writes to it before allowing workspace writes:
reference the profile construction code that uses workspace and data_dir
variables and add a deny file-write* (subpath "<data_dir>") entry (or otherwise
constrain the workspace allow to exclude data_dir) so that macOS profiles cannot
grant write access to data_dir when workspace overlaps it.
- Around line 163-168: self.workspace and entries in self.writable_paths are
passed raw to bwrap, which lets symlinks escape the intended sandbox and fails
on missing paths; canonicalize the paths before adding bind args: call
std::fs::canonicalize on self.workspace and each writable in the code that
builds cmd (the block that uses cmd.arg("--bind").arg(&self.workspace)... and
the for writable in &self.writable_paths loop), handle errors by either creating
missing directories or returning a descriptive error, dedupe canonical paths
(e.g. with a HashSet) to avoid duplicate binds, and use the canonicalized path
values for both source and destination when invoking cmd.arg("--bind").

In `@src/tools/send_file.rs`:
- Around line 40-63: validate_workspace_path currently allows any path when
self.workspace is None which enables file exfiltration; change it to return a
SendFileError when workspace is unset (fail-closed) instead of returning
Ok(path), and update all call sites to call with_workspace (or otherwise ensure
a workspace is provided) before invoking validate_workspace_path so callers can
no longer bypass workspace checks; refer to the validate_workspace_path function
and the SendFileError type and adjust callers that currently rely on the None
behavior to use with_workspace to supply a workspace.

---

Nitpick comments:
In `@docs/content/docs/`(features)/workers.mdx:
- Around line 146-158: The docs state sandboxing behavior but can be misread as
full system isolation; update the "Sandbox and Protected Paths" section to
explicitly state that sandbox isolation covers filesystem containment only
(read-only host FS, writable workspace, sandbox-specific writable_paths) and
does NOT provide network, CPU, or other resource isolation; reference the
file-tool guard (blocks SOUL.md, IDENTITY.md, USER.md), resolve_path(), and exec
tool environment-variable checks so readers know which protections exist.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fba2b7e and cbdb245.

📒 Files selected for processing (17)
  • Dockerfile
  • docs/content/docs/(configuration)/config.mdx
  • docs/content/docs/(features)/tools.mdx
  • docs/content/docs/(features)/workers.mdx
  • docs/content/docs/(getting-started)/docker.mdx
  • docs/design-docs/sandbox.md
  • src/agent/worker.rs
  • src/api/agents.rs
  • src/api/state.rs
  • src/config.rs
  • src/lib.rs
  • src/main.rs
  • src/sandbox.rs
  • src/tools.rs
  • src/tools/exec.rs
  • src/tools/send_file.rs
  • src/tools/shell.rs

Comment on lines +524 to +551
### `[agents.sandbox]`

OS-level filesystem containment for shell and exec tool subprocesses. Uses bubblewrap (Linux) or sandbox-exec (macOS) to enforce read-only access to everything outside the workspace.

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `mode` | string | `"enabled"` | `"enabled"` for kernel-enforced containment, `"disabled"` for full host access |
| `writable_paths` | string[] | `[]` | Additional directories the agent can write to beyond its workspace |

When `mode = "enabled"`, shell and exec commands run inside a mount namespace where the entire filesystem is read-only except:

- The agent's workspace directory
- `/tmp` (private per invocation)
- `/dev` (standard device nodes)
- Any paths listed in `writable_paths`

The agent's data directory (databases, config) is explicitly re-mounted read-only even if it would otherwise be writable due to path overlap.

When `SPACEBOT_DEPLOYMENT=hosted`, sandbox mode is always `"enabled"` regardless of config.

If the sandbox backend isn't available (e.g. bubblewrap not installed), processes run unsandboxed with a warning at startup.

```toml
[agents.sandbox]
mode = "enabled"
writable_paths = ["/home/user/projects/myapp", "/var/data/shared"]
```

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for SPACEBOT_DEPLOYMENT references
echo "=== Searching for SPACEBOT_DEPLOYMENT ==="
rg -n "SPACEBOT_DEPLOYMENT" --type=rs -C3

echo -e "\n=== Searching for hosted deployment references ==="
rg -n "hosted" --type=rs -C2 | head -80

echo -e "\n=== Searching for sandbox mode enforcement ==="
rg -n "sandbox.*enabled\|mode.*enabled" --type=rs -C2 | head -80

Repository: spacedriveapp/spacebot

Length of output: 305


🏁 Script executed:

#!/bin/bash
# Search for SPACEBOT_DEPLOYMENT without type restriction
echo "=== Searching for SPACEBOT_DEPLOYMENT ==="
rg -n "SPACEBOT_DEPLOYMENT" -C3 2>/dev/null | head -100

echo -e "\n=== Searching for 'hosted' deployment references ==="
rg -n "hosted" -C2 2>/dev/null | grep -E "\.rs:|sandbox|deployment" | head -100

echo -e "\n=== Finding all Rust files to check structure ==="
fd -e rs | head -20

echo -e "\n=== Searching for sandbox config loading ==="
rg -n "agents.*sandbox\|sandbox.*mode" -C2 2>/dev/null | head -100

Repository: spacedriveapp/spacebot

Length of output: 12028


Documentation claim about SPACEBOT_DEPLOYMENT=hosted sandbox enforcement is unsupported.

While SPACEBOT_DEPLOYMENT=hosted is properly detected in the codebase (used to enforce API bind settings), there is no implementation that forces sandbox mode = "enabled" when hosted is set. The codebase searches show hosted-aware logic only for API configuration, not sandbox mode.

The design doc references "platform boot script forces mode", suggesting this is external infrastructure enforcement rather than app-level logic. Either clarify in the user-facing documentation that this is platform-enforced externally (not in the app config), or remove the claim if it's not actually guaranteed on the hosted platform.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/`(configuration)/config.mdx around lines 524 - 551, The
docs incorrectly state that setting SPACEBOT_DEPLOYMENT=hosted forces
agents.sandbox.mode = "enabled" in-app; update the `[agents.sandbox]` section to
either remove that guarantee or explicitly state that sandbox enforcement is
performed by external platform infrastructure (e.g., "platform boot script")
rather than by application logic. Reference the config key `agents.sandbox`, the
env var `SPACEBOT_DEPLOYMENT=hosted`, and the design note about the platform
boot script so readers know this is a platform-level enforcement and not
enforced by any in-app function or config migration.

Comment on lines +19 to +35
| Item | Lines | What it does | Disposition |
|------|-------|-------------|-------------|
| `SENSITIVE_FILES` constant | 12-18 | Blocklist of 5 filenames: `config.toml`, `config.redb`, `settings.redb`, `.env`, `spacebot.db` | **Remove.** Sandbox makes these read-only via mount namespace. |
| `SECRET_ENV_VARS` constant | 21-30 | Blocklist of 8 env var names: API keys for Anthropic, OpenAI, OpenRouter, Discord, Slack (bot + app), Telegram, Brave Search | **Remove.** Sandbox inherits the parent's env, but the sandbox blocks reading `/proc/self/environ`. Leak detection in `SpacebotHook` catches any secrets that make it into tool output. |
| `instance_dir` field | 36 | Stored on `ShellTool` for path blocking comparisons | **Remove.** No longer needed — sandbox handles containment. |
| `check_command()` method | 50-232 | Pre-execution string inspection with 9 categories of checks (detailed below) | **Remove entirely.** All 182 lines. |
| — Instance dir blocking | 53-69 | Blocks commands containing the instance dir path (unless they also mention workspace) | Replaced by bwrap `--ro-bind` making the instance dir read-only |
| — Sensitive file blocking | 71-90 | Blocks commands mentioning any of the 5 `SENSITIVE_FILES` names unless clearly targeting workspace | Replaced by bwrap mount namespace — files aren't writable |
| — Secret env var expansion | 92-118 | Blocks `$VAR`, `${VAR}`, `printenv VAR` patterns for 8 secret var names, including unbraced `$VAR` at word boundaries | Replaced by sandbox env isolation + leak detection hook |
| — Broad env dumps | 120-144 | Blocks bare `printenv`, `env`, `env |`, `env >` | Replaced by sandbox env isolation + leak detection hook |
| — Shell builtin dumps | 146-163 | Blocks `set`, `declare -p`, `export -p`, `compgen -e`, `compgen -v` as standalone commands or in pipes/chains | Replaced by sandbox env isolation + leak detection hook |
| — Subshell blocking | 165-178 | Blocks backticks, `$(...)`, `<(...)`, `>(...)` | **Remove.** These were only blocked to prevent dynamic construction of blocked commands. With real sandboxing, there's nothing to dynamically bypass. |
| — eval/exec blocking | 180-186 | Blocks `eval` and `exec` builtins | **Remove.** Same rationale — no string checks to bypass. |
| — Interpreter one-liners | 188-205 | Blocks `python3 -c`, `python -c`, `perl -e`, `ruby -e`, `node -e`, `node --eval` | **Remove.** These were blocked to prevent bypassing the string checks via a different language. Sandbox makes this irrelevant. |
| — /proc and /dev paths | 207-217 | Blocks `/proc/self/environ`, `/proc/*/environ`, `/dev/fd/`, `/dev/stdin` | Replaced by bwrap `--proc /proc` (fresh procfs) + leak detection hook |
| — Shell state dumps | 219-229 | Blocks `set`, `declare -p`, `compgen`, `export` builtins (partial duplicate of lines 146-163) | Replaced by sandbox env isolation + leak detection hook |
| `contains_shell_builtin()` helper | 423-434 | Splits command on `|;& ` and checks if a segment starts with a given builtin | **Remove.** Only used by `check_command()`. |
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Escape pipe characters inside table code spans.
markdownlint flags extra columns and code-span spacing (MD056/MD038) around entries like env | / env >. Escape the pipe (\|) or use an HTML entity to keep the table at 4 columns.

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 28-28: Spaces inside code span elements

(MD038, no-space-in-code)


[warning] 28-28: Table column count
Expected: 4; Actual: 5; Too many cells, extra data will be missing

(MD056, table-column-count)


[warning] 35-35: Table column count
Expected: 4; Actual: 5; Too many cells, extra data will be missing

(MD056, table-column-count)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/design-docs/sandbox.md` around lines 19 - 35, Summary: Markdown table
code spans containing pipe characters (e.g., `env |` and `env >`) need escaping
to satisfy markdownlint rules MD056/MD038. Fix: in the sandbox.md table replace
any inline code spans that include a pipe with an escaped pipe (e.g., `env \|`
and `env \>`) or use the HTML entity (e.g., `env &#124;`) so the table remains
four columns; update any similar entries (like `env |`, `env >`, or other code
spans containing `|`) throughout the table rows describing SECRET_ENV_VARS,
broad env dumps, and related entries to ensure markdownlint no longer flags
extra columns.

Comment on lines +187 to +201
```
bwrap
--ro-bind / / # entire filesystem read-only
--dev /dev # writable /dev with standard nodes
--proc /proc # fresh /proc (skipped if preflight failed)
--tmpfs /tmp # private tmp per invocation
--bind <workspace> <workspace> # workspace writable
--bind <writable_path> <writable_path> # each configured writable path
--ro-bind <data_dir> <data_dir> # re-protect agent data dir
--unshare-pid # isolated PID namespace
--new-session # prevent TIOCSTI TTY injection
--die-with-parent # kill child if spacebot dies
--chdir <working_dir> # set cwd
-- sh -c "<command>" # the actual command
```
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language identifiers to fenced code blocks.
markdownlint MD040 expects language tags on these blocks; add tags like sh, scheme, or ```text.

Also applies to: 212-219, 370-376

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 187-187: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/design-docs/sandbox.md` around lines 187 - 201, The fenced code blocks
in the sandbox documentation (the bwrap command block and the other blocks at
the noted ranges) are missing language identifiers; update each triple-backtick
fence to include an appropriate language tag (e.g., change ``` to ```sh for
shell snippets like the bwrap block, or use ```text/```sh/```scheme as
appropriate for the other blocks) so markdownlint MD040 is satisfied, ensuring
the fences around the bwrap snippet and the blocks at the other reported ranges
are consistently tagged.

Comment on lines 236 to 292
fn generate_sbpl_profile(&self) -> String {
let workspace = canonicalize_or_self(&self.workspace);

let mut profile = String::from(
r#"(version 1)
(deny default)

; process basics
(allow process-exec)
(allow process-fork)
(allow signal (target same-sandbox))
(allow process-info* (target same-sandbox))

; filesystem: read everything
(allow file-read*)

"#,
);

// Workspace writable
profile.push_str(&format!(
"; workspace writable\n(allow file-write* (subpath \"{}\"))\n\n",
workspace.display()
));

// Additional writable paths
for (index, writable) in self.writable_paths.iter().enumerate() {
let canonical = canonicalize_or_self(writable);
profile.push_str(&format!(
"; writable path {index}\n(allow file-write* (subpath \"{}\"))\n",
canonical.display()
));
}

// /tmp writable
let tmp = canonicalize_or_self(Path::new("/tmp"));
profile.push_str(&format!(
"\n; tmp writable\n(allow file-write* (subpath \"{}\"))\n",
tmp.display()
));

profile.push_str(
r#"
; dev, sysctl, mach for basic operation
(allow file-write-data
(require-all (path "/dev/null") (vnode-type CHARACTER-DEVICE)))
(allow sysctl-read)
(allow mach-lookup
(global-name "com.apple.system.opendirectoryd.libinfo"))
(allow ipc-posix-sem)
(allow pseudo-tty)
(allow network*)
"#,
);

profile
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

SBPL profile is vulnerable to path injection via unescaped quotes.

Paths from workspace and writable_paths are interpolated directly into SBPL strings using format!. If any path contains a double-quote ("), it breaks the SBPL syntax and could inject arbitrary profile rules. While these paths are admin-configured, defense-in-depth dictates escaping or rejecting paths with special characters.

🛡️ Proposed fix: sanitize paths before SBPL interpolation

Add a helper and use it before interpolation:

+/// Escape a path for safe inclusion in an SBPL string literal.
+/// SBPL uses Scheme-style string literals — backslash-escape quotes.
+fn sbpl_escape(path: &std::path::Path) -> String {
+    path.display().to_string().replace('\\', "\\\\").replace('"', "\\\"")
+}
+
 fn generate_sbpl_profile(&self) -> String {
     let workspace = canonicalize_or_self(&self.workspace);
     // ...
-    workspace.display()
+    sbpl_escape(&workspace)
     // ... and similarly for all other path interpolations
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox.rs` around lines 236 - 292, generate_sbpl_profile interpolates
filesystem paths directly into SBPL strings, allowing path injection if a path
contains quotes or other special characters; fix by creating a small sanitizer
(e.g., escape double-quotes and backslashes or validate/reject characters) and
use it on values returned by canonicalize_or_self for workspace, each entry in
self.writable_paths, and the tmp path before calling format! so that strings
inserted into the profile are SBPL-safe; reference generate_sbpl_profile,
canonicalize_or_self, and self.writable_paths when updating the code.

Comment on lines +322 to +343
// Preflight: test if --proc /proc works (may fail in nested containers)
let proc_check = Command::new("bwrap")
.args([
"--ro-bind",
"/",
"/",
"--proc",
"/proc",
"--",
"/usr/bin/true",
])
.output()
.await;

let proc_supported = proc_check.is_ok_and(|output| output.status.success());

if !proc_supported {
tracing::debug!("bwrap --proc /proc not supported, running without fresh procfs");
}

SandboxBackend::Bubblewrap { proc_supported }
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, check if src/sandbox.rs exists and matches the code shown
git ls-files | grep sandbox.rs

Repository: spacedriveapp/spacebot

Length of output: 80


🏁 Script executed:

# Check the Dockerfile for base image
if [ -f Dockerfile ]; then
  echo "=== Dockerfile content ===" 
  head -20 Dockerfile
else
  echo "Dockerfile not found, searching for Dockerfiles in repo"
  find . -name "Dockerfile*" -type f 2>/dev/null | head -5
fi

Repository: spacedriveapp/spacebot

Length of output: 677


🏁 Script executed:

# Read the specific lines from src/sandbox.rs to verify the code
if [ -f src/sandbox.rs ]; then
  echo "=== Lines 322-343 of src/sandbox.rs ==="
  sed -n '322,343p' src/sandbox.rs
fi

Repository: spacedriveapp/spacebot

Length of output: 693


🏁 Script executed:

# Search for other uses of external commands in the codebase to understand patterns
echo "=== Searching for Command::new usage in Rust files ==="
rg "Command::new" -A 3 --type rust | head -50

Repository: spacedriveapp/spacebot

Length of output: 2207


🏁 Script executed:

# Check for complete Dockerfile - look for final/runtime stage
cat Dockerfile | tail -30

Repository: spacedriveapp/spacebot

Length of output: 806


🏁 Script executed:

# Search for any references to Alpine or minimal images in docs/comments
rg "alpine|minimal|slim|scratch" -i --type rust --type toml --type md | head -20

Repository: spacedriveapp/spacebot

Length of output: 1788


🏁 Script executed:

# Check if there's any platform/OS detection in the code
rg "cfg.*target_os\|target_family" --type rust | head -10

Repository: spacedriveapp/spacebot

Length of output: 48


🏁 Script executed:

# Look for any deployment or runtime documentation
find . -type f \( -name "*.md" -o -name "*.txt" \) -exec grep -l "docker\|container\|image\|deploy" {} \; 2>/dev/null | head -5

Repository: spacedriveapp/spacebot

Length of output: 211


🏁 Script executed:

# Get the full Dockerfile to find the actual runtime base image
cat Dockerfile | grep -A 5 "^FROM"

Repository: spacedriveapp/spacebot

Length of output: 554


🏁 Script executed:

# Check if /usr/bin/true is available on debian:bookworm-slim
# Research the standard paths on Debian
rg "debian|bookworm" Dockerfile | head -3

Repository: spacedriveapp/spacebot

Length of output: 129


🏁 Script executed:

# Verify portability of /bin/sh vs /usr/bin/true
# Check FHS (Filesystem Hierarchy Standard) compliance
web_search
/bin/sh availability on Linux systems FHS standard POSIX

Repository: spacedriveapp/spacebot

Length of output: 165


🏁 Script executed:

# Check if there's any conditional logic around the sandbox detection
rg "proc_supported" --type rust -A 2 -B 2

Repository: spacedriveapp/spacebot

Length of output: 1835


🏁 Script executed:

# Search for any documentation about supported platforms/distros
rg "platform|distro|ubuntu|alpine|arch|fedora" -i --type md README.md docs/

Repository: spacedriveapp/spacebot

Length of output: 29006


🏁 Script executed:

# Check if true is explicitly available or if coreutils is assumed
# Look at what packages are installed in the slim image
grep -A 20 "FROM debian:bookworm-slim" Dockerfile | grep -E "apt-get|RUN"

Repository: spacedriveapp/spacebot

Length of output: 181


🏁 Script executed:

# Verify the exact semantics: does /bin/sh -c "exit 0" behave identically to /usr/bin/true?
# And check if /bin/sh is indeed guaranteed on Debian
cat > /tmp/check_portability.md << 'EOF'
# POSIX/FHS Standards Check

## /bin/sh
- POSIX standard: REQUIRED
- FHS (Filesystem Hierarchy Standard): REQUIRED
- Status: Guaranteed on all POSIX-compliant systems

## /usr/bin/true
- Location varies by system:
  - On GNU/Linux systems (Debian, Ubuntu, Fedora): /usr/bin/true (from coreutils)
  - Some minimal systems: may be in /bin/true or implement as shell builtin
  - Alpine: part of coreutils, at /bin/true (not /usr/bin/true)
  - Busybox systems: may vary

## Semantics
- /usr/bin/true: exits with status 0
- /bin/sh -c "exit 0": also exits with status 0
- Both work for testing: bwrap will execute and exit with success, regardless of --proc support

## The real issue
If the inner command doesn't exist (missing /usr/bin/true):
- bwrap will fail to execute it
- The preflight test returns failure
- proc_supported = false
- BUT the actual reason isn't that --proc doesn't work; it's that true doesn't exist
- This masks the real test result
EOF
cat /tmp/check_portability.md

Repository: spacedriveapp/spacebot

Length of output: 1018


🏁 Script executed:

# Check if there are any other similar portability issues in sandbox.rs
grep -n "/usr/bin\|/bin/" src/sandbox.rs

Repository: spacedriveapp/spacebot

Length of output: 335


Use /bin/sh instead of /usr/bin/true for broader portability.

The preflight test for bwrap --proc support uses /usr/bin/true, which exists on Debian-based systems but is at /bin/true on Alpine and other minimal distributions. If the binary runs on a system without /usr/bin/true, bwrap will fail to execute the inner command, and proc_supported will incorrectly report false — masking the real issue (missing true) as a lack of procfs support.

Use /bin/sh -c "exit 0" instead, which is POSIX-guaranteed on all systems:

Diff
         "--proc",
         "/proc",
         "--",
-        "/usr/bin/true",
+        "/bin/sh",
+        "-c",
+        "exit 0",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Preflight: test if --proc /proc works (may fail in nested containers)
let proc_check = Command::new("bwrap")
.args([
"--ro-bind",
"/",
"/",
"--proc",
"/proc",
"--",
"/usr/bin/true",
])
.output()
.await;
let proc_supported = proc_check.is_ok_and(|output| output.status.success());
if !proc_supported {
tracing::debug!("bwrap --proc /proc not supported, running without fresh procfs");
}
SandboxBackend::Bubblewrap { proc_supported }
}
// Preflight: test if --proc /proc works (may fail in nested containers)
let proc_check = Command::new("bwrap")
.args([
"--ro-bind",
"/",
"/",
"--proc",
"/proc",
"--",
"/bin/sh",
"-c",
"exit 0",
])
.output()
.await;
let proc_supported = proc_check.is_ok_and(|output| output.status.success());
if !proc_supported {
tracing::debug!("bwrap --proc /proc not supported, running without fresh procfs");
}
SandboxBackend::Bubblewrap { proc_supported }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox.rs` around lines 322 - 343, The preflight bwrap check currently
runs "/usr/bin/true" which isn't portable; replace that inner command with a
POSIX-guaranteed shell invocation by calling bwrap with the final args "--",
"/bin/sh", "-c", "exit 0" so the Command created for the proc_check uses
"/bin/sh -c 'exit 0'" instead of "/usr/bin/true"; update the args array used to
build proc_check (the Command::new("bwrap") invocation and its args) so
proc_supported correctly reflects bwrap/--proc support on minimal systems.

Comment on lines +202 to 207
let arg_refs: Vec<&str> = args.args.iter().map(|s| s.as_str()).collect();
let mut cmd = self.sandbox.wrap(&args.program, &arg_refs, &working_dir);

// Apply user-specified env vars after sandbox wrapping
for env_var in args.env {
cmd.env(env_var.key, env_var.value);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid abbreviated variable name arg_refs.

Rename to argument_refs (or similar) for guideline compliance. As per coding guidelines: "Don't abbreviate variable names (use queue not q, message not msg, channel not ch). Common abbreviations like config are fine".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/exec.rs` around lines 202 - 207, The variable name arg_refs is
abbreviated; rename it to a full descriptive name like argument_refs across this
function so it follows naming guidelines: change the declaration let arg_refs:
Vec<&str> = args.args.iter().map(|s| s.as_str()).collect(); to let
argument_refs: Vec<&str> = ... and update its usage in the call to
self.sandbox.wrap(&args.program, &arg_refs, &working_dir) (and any other
references) to use &argument_refs instead; ensure the new name is used
consistently and the type/signature remains unchanged.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
src/sandbox.rs (2)

262-325: SBPL profile paths are not escaped — injection risk from paths containing double quotes.

Paths from workspace, writable_paths, and data_dir are interpolated directly into SBPL string literals via format!. A path containing " would break SBPL syntax and could inject arbitrary rules. While these are admin-configured, defense-in-depth warrants sanitization.

Also, canonicalize_or_self is called again in generate_sbpl_profile for paths that were already canonicalized in Sandbox::new. This is harmless but redundant.

Proposed helper
+/// Escape a path for safe inclusion in an SBPL string literal.
+fn sbpl_escape(path: &Path) -> String {
+    path.display()
+        .to_string()
+        .replace('\\', "\\\\")
+        .replace('"', "\\\"")
+}

Then use sbpl_escape(&workspace) instead of workspace.display() in all SBPL interpolations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox.rs` around lines 262 - 325, The SBPL profile inserts filesystem
paths directly in generate_sbpl_profile which allows a path with double quotes
to break SBPL syntax; add a helper like sbpl_escape(path: &Path) -> String that
returns the path string with double quotes and any SBPL-special characters
escaped (use it when formatting strings for workspace, each entry in
writable_paths, data_dir, and tmp), replace all calls that use
workspace.display()/canonical.display() with sbpl_escape(&canonical) and avoid
redundant canonicalize_or_self calls if paths are already canonicalized earlier
(or at least call canonicalize_or_self once per path and reuse the result) so
all interpolated path values are safely quoted/escaped before being inserted
into the SBPL profile.

355-367: /usr/bin/true is not portable to all Linux distributions.

On Alpine, Busybox-based, or other minimal distributions, true may live at /bin/true or be a shell builtin only. A failed inner command would make proc_supported report false for the wrong reason.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox.rs` around lines 355 - 367, The preflight bwrap invocation
hardcodes "/usr/bin/true" which is not portable; change the inner command passed
to Command::new("bwrap") in sandbox.rs (the proc_check call) to use a portable
executable name (e.g. "true" without an absolute path) or try a small fallback
resolution (attempt "/usr/bin/true" then "/bin/true" or use "true" so PATH
lookup can find a distro-specific location) so a missing hardcoded path doesn't
cause proc_supported to be misreported; update the args array passed to
Command::new("bwrap") accordingly (the place that currently contains "--",
"/usr/bin/true").
🧹 Nitpick comments (4)
tests/context_dump.rs (1)

340-340: bootstrap_deps() is duplicated between tests/bulletin.rs and tests/context_dump.rs.

Both test files contain a near-identical ~80-line bootstrap_deps() function. Consider extracting it into a shared test helper (e.g., a tests/common/mod.rs or a test_support module) to reduce maintenance burden when fields like sandbox are added to AgentDeps.

Also applies to: 495-495

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/context_dump.rs` at line 340, Extract the duplicated bootstrap_deps()
helper into a shared test helper module (e.g., tests/common/mod.rs or
tests/test_support.rs), move the existing implementation (including construction
of AgentDeps and usage of deps.sandbox) into that module as a single pub
function, replace the duplicate definitions in tests/context_dump.rs and
tests/bulletin.rs with a use/import of the shared bootstrap_deps, and update any
test imports so they call the shared function; ensure the shared helper exports
the same signature and uses AgentDeps so changes to AgentDeps (like adding
sandbox) only need updating in one place.
src/tools/send_file.rs (1)

38-40: Asymmetric canonicalization: workspace falls back silently while file path must resolve.

If self.workspace can't be canonicalized (e.g., it doesn't exist yet), the fallback to the raw path means starts_with compares a fully resolved file path against a potentially unresolved workspace path. This is fail-closed (legitimate files get rejected with a confusing "ACCESS DENIED" error), not fail-open, so it's safe — but it could be surprising at runtime.

Consider logging a warning when workspace canonicalization fails, similar to how sandbox.rs handles writable_paths:

Proposed improvement
-        let workspace_canonical = workspace
-            .canonicalize()
-            .unwrap_or_else(|_| workspace.clone());
+        let workspace_canonical = workspace.canonicalize().unwrap_or_else(|error| {
+            tracing::warn!(
+                workspace = %workspace.display(),
+                %error,
+                "send_file: workspace canonicalization failed, using raw path"
+            );
+            workspace.clone()
+        });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/send_file.rs` around lines 38 - 40, The workspace canonicalization
currently falls back silently to the original path (let workspace_canonical =
workspace.canonicalize().unwrap_or_else(|_| workspace.clone());) which can cause
comparisons like starts_with to mismatch against fully-resolved file paths;
update the logic in send_file.rs to attempt canonicalize on self.workspace but
log a warning when canonicalize fails (including the error and the original
workspace string) before using the fallback, mirroring the approach in
sandbox.rs for writable_paths, so callers see why comparisons may behave
unexpectedly.
src/sandbox.rs (2)

333-343: cfg!(target_os = ...) compiles to a constant — both branches are compiled on all platforms.

This is fine for correctness (the unused branch is dead code that the compiler will optimize away), but worth noting: if detect_bubblewrap or detect_sandbox_exec ever gain platform-specific dependencies, you'd need #[cfg(target_os = "...")] attributes instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox.rs` around lines 333 - 343, The current detect_backend() uses
runtime cfg!(target_os = "...") which compiles both branches; change to
conditional compilation so platform-specific functions aren’t compiled on other
OSes: annotate the detect_bubblewrap() call with #[cfg(target_os = "linux")] and
the detect_sandbox_exec() call with #[cfg(target_os = "macos")], and provide a
#[cfg(not(any(target_os = "linux", target_os = "macos")))] fallback that logs
and returns SandboxBackend::None; keep function name detect_backend and calls
detect_bubblewrap/detect_sandbox_exec so locating the changes is
straightforward.

265-278: (allow process-exec) without path restriction allows executing any binary on the system.

The SBPL profile grants unrestricted process-exec, meaning any binary readable on the host can be executed inside the sandbox. While filesystem writes are contained, this allows running tools like curl, nc, python etc. for data exfiltration over the network (since (allow network*) is also granted). This is consistent with the PR's stated scope (no network isolation), but worth documenting as a known limitation in the profile comments.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox.rs` around lines 265 - 278, The SBPL profile currently includes
an unrestricted (allow process-exec) entry inside the profile string (the
`profile` variable) which permits executing any readable host binary; update the
profile text to include a short comment adjacent to the (allow process-exec)
line documenting this known limitation and the associated risk (e.g., ability to
run host tools like curl/python for exfiltration when network is allowed), or—if
intended—replace it with a path-restricted form (e.g., restricting to specific
runtime binaries) and mention that choice in the comment so reviewers understand
the trade-off.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/sandbox.rs`:
- Around line 280-301: The SBPL deny for data_dir is placed after the workspace
allow so the allow can match first and override the deny; move the data_dir
block that builds the deny rule (the code using
canonicalize_or_self(&self.data_dir) and the profile.push_str call that writes
"(deny file-write* (subpath ...))") to before the workspace writable block (the
section that writes the workspace allow and the loop over self.writable_paths),
ensuring the data_dir deny is emitted before any "(allow file-write*)" rules so
it takes precedence.

---

Duplicate comments:
In `@src/sandbox.rs`:
- Around line 262-325: The SBPL profile inserts filesystem paths directly in
generate_sbpl_profile which allows a path with double quotes to break SBPL
syntax; add a helper like sbpl_escape(path: &Path) -> String that returns the
path string with double quotes and any SBPL-special characters escaped (use it
when formatting strings for workspace, each entry in writable_paths, data_dir,
and tmp), replace all calls that use workspace.display()/canonical.display()
with sbpl_escape(&canonical) and avoid redundant canonicalize_or_self calls if
paths are already canonicalized earlier (or at least call canonicalize_or_self
once per path and reuse the result) so all interpolated path values are safely
quoted/escaped before being inserted into the SBPL profile.
- Around line 355-367: The preflight bwrap invocation hardcodes "/usr/bin/true"
which is not portable; change the inner command passed to Command::new("bwrap")
in sandbox.rs (the proc_check call) to use a portable executable name (e.g.
"true" without an absolute path) or try a small fallback resolution (attempt
"/usr/bin/true" then "/bin/true" or use "true" so PATH lookup can find a
distro-specific location) so a missing hardcoded path doesn't cause
proc_supported to be misreported; update the args array passed to
Command::new("bwrap") accordingly (the place that currently contains "--",
"/usr/bin/true").

---

Nitpick comments:
In `@src/sandbox.rs`:
- Around line 333-343: The current detect_backend() uses runtime cfg!(target_os
= "...") which compiles both branches; change to conditional compilation so
platform-specific functions aren’t compiled on other OSes: annotate the
detect_bubblewrap() call with #[cfg(target_os = "linux")] and the
detect_sandbox_exec() call with #[cfg(target_os = "macos")], and provide a
#[cfg(not(any(target_os = "linux", target_os = "macos")))] fallback that logs
and returns SandboxBackend::None; keep function name detect_backend and calls
detect_bubblewrap/detect_sandbox_exec so locating the changes is
straightforward.
- Around line 265-278: The SBPL profile currently includes an unrestricted
(allow process-exec) entry inside the profile string (the `profile` variable)
which permits executing any readable host binary; update the profile text to
include a short comment adjacent to the (allow process-exec) line documenting
this known limitation and the associated risk (e.g., ability to run host tools
like curl/python for exfiltration when network is allowed), or—if
intended—replace it with a path-restricted form (e.g., restricting to specific
runtime binaries) and mention that choice in the comment so reviewers understand
the trade-off.

In `@src/tools/send_file.rs`:
- Around line 38-40: The workspace canonicalization currently falls back
silently to the original path (let workspace_canonical =
workspace.canonicalize().unwrap_or_else(|_| workspace.clone());) which can cause
comparisons like starts_with to mismatch against fully-resolved file paths;
update the logic in send_file.rs to attempt canonicalize on self.workspace but
log a warning when canonicalize fails (including the error and the original
workspace string) before using the fallback, mirroring the approach in
sandbox.rs for writable_paths, so callers see why comparisons may behave
unexpectedly.

In `@tests/context_dump.rs`:
- Line 340: Extract the duplicated bootstrap_deps() helper into a shared test
helper module (e.g., tests/common/mod.rs or tests/test_support.rs), move the
existing implementation (including construction of AgentDeps and usage of
deps.sandbox) into that module as a single pub function, replace the duplicate
definitions in tests/context_dump.rs and tests/bulletin.rs with a use/import of
the shared bootstrap_deps, and update any test imports so they call the shared
function; ensure the shared helper exports the same signature and uses AgentDeps
so changes to AgentDeps (like adding sandbox) only need updating in one place.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cbdb245 and b0ae7ae.

📒 Files selected for processing (6)
  • src/config.rs
  • src/sandbox.rs
  • src/tools.rs
  • src/tools/send_file.rs
  • tests/bulletin.rs
  • tests/context_dump.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/config.rs

Comment on lines +280 to +301

// Workspace writable
profile.push_str(&format!(
"; workspace writable\n(allow file-write* (subpath \"{}\"))\n\n",
workspace.display()
));

// Additional writable paths
for (index, writable) in self.writable_paths.iter().enumerate() {
let canonical = canonicalize_or_self(writable);
profile.push_str(&format!(
"; writable path {index}\n(allow file-write* (subpath \"{}\"))\n",
canonical.display()
));
}

// Protect data_dir even if it falls under the workspace subtree
let data_dir = canonicalize_or_self(&self.data_dir);
profile.push_str(&format!(
"\n; data dir read-only\n(deny file-write* (subpath \"{}\"))\n",
data_dir.display()
));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Apple SBPL sandbox profile language rule evaluation order first match wins

💡 Result:

In Apple’s Seatbelt sandbox (SBPL), an operation’s decision is made by the first rule/test in the operation’s compiled rule list whose filter matches (“first match decides”). [1]

However, SBPL profiles are commonly written and behave (in practice) like later rules override earlier ones—i.e., for a given operation/resource, the last applicable rule in the profile text tends to determine the outcome (“last match wins”) because later rules end up taking precedence in the effective rule set. [2][3]

Sources:
[1] Dionysus Blazakis, The Apple Sandbox (SBPL stub comment on rule evaluation: “decided by the first test… that matches”) (researchgate.net)
[2] Ubrigens, A Whirlwind Tour of the Apple Sandbox (later rules overwrite preceding; “last applicable rule… guides the final… decision”) (ubrigens.com)
[3] Ubrigens, Exploring Sandbox Coverage on macOS (“Later rules can modify earlier ones…”) (ubrigens.com)

Citations:


🏁 Script executed:

# First, let's find and examine the src/sandbox.rs file to understand the context better
fd -t f "sandbox.rs"

Repository: spacedriveapp/spacebot

Length of output: 80


🏁 Script executed:

# Read the relevant section of src/sandbox.rs
wc -l src/sandbox.rs

Repository: spacedriveapp/spacebot

Length of output: 84


🏁 Script executed:

# Get the code context around lines 280-301
sed -n '250,320p' src/sandbox.rs | cat -n

Repository: spacedriveapp/spacebot

Length of output: 2663


🌐 Web query:

SBPL "deny" "allow" rule precedence macOS sandbox last match wins first match

💡 Result:

In macOS SBPL/Seatbelt, it’s not “last match wins.”

  • The engine searches for a matching rule and stops at the first explicit match; evaluation terminates as soon as it reaches an allow or deny action. So later rules don’t override earlier ones. [1], [2]
  • The default action is only used if no explicit rule matches, and it can be placed anywhere in the profile. [1]
  • Consequence: if a (deny …) appears before an equivalent (allow …) for the same operation/target, the allow will never be reached (i.e., it will be denied). [1]

Sources: Apple’s Sandbox Guide v1.0 [1]; Mark Rowe’s “Sandboxing on macOS” (policy evaluation terminates on first action reached) [2].


🌐 Web query:

Apple macOS Sandbox Profile Language SBPL official documentation rule evaluation

💡 Result:

Apple does not publish official documentation for the macOS Sandbox Profile Language (SBPL) itself (grammar/semantics/rule evaluation). Apple’s public docs focus on App Sandbox entitlements/capabilities, while SBPL remains an internal/private mechanism; projects that embed SBPL generally note the lack of official OS-provided SBPL documentation. [2]

What is known about SBPL rule evaluation comes primarily from reverse-engineering and third-party analyses of libsandbox/Seatbelt:

  • SBPL evaluation produces a per-operation rules table mapping operation codes to an ordered list of rules. [1]
  • At runtime, an operation’s result is determined by the first rule (“test”) whose filter matches (a #t filter matches unconditionally). [1]
  • SBPL supports “jumps”: a matching rule can transfer evaluation to the rules list of another operation. [1]
  • Conceptually, libsandbox uses an embedded Scheme interpreter to evaluate SBPL (SBPL can include Scheme that generates rules), then compiles the resulting rules into sandbox bytecode enforced by the kernel component. [3]

Sources
[1] “The Apple Sandbox” (describes rules table, first-match semantics, and jumps) (researchgate.net)
[2] Chromium macOS Seatbelt sandbox design doc (states no official SBPL documentation) (chromium.googlesource.com)
[3] Ubrigens: “A Whirlwind Tour of the Apple Sandbox” (explains SBPL/Scheme evaluation in libsandbox) (ubrigens.com)

Citations:


🏁 Script executed:

# Check the full sandbox.rs to understand how workspace and data_dir are set
head -100 src/sandbox.rs | cat -n

Repository: spacedriveapp/spacebot

Length of output: 3912


🏁 Script executed:

# Look for SandboxConfig struct definition and initialization
rg -A 10 "struct.*SandboxConfig" src/sandbox.rs

Repository: spacedriveapp/spacebot

Length of output: 336


🏁 Script executed:

# Search for Sandbox::new() calls to see how workspace and data_dir relate
rg -B 3 -A 3 "Sandbox::new" src/

Repository: spacedriveapp/spacebot

Length of output: 725


🏁 Script executed:

# Also check the learning note about workspace path guard
# The learning mentions "identity/memory paths" - check if data_dir is related
rg -i "data_dir|identity|memory" src/sandbox.rs | head -20

Repository: spacedriveapp/spacebot

Length of output: 428


🏁 Script executed:

# Find where data_dir is passed to Sandbox::new() - need to see what instance_dir is
rg -B 5 "Sandbox::new" src/main.rs | head -30

Repository: spacedriveapp/spacebot

Length of output: 235


🏁 Script executed:

# Check what instance_dir points to
rg "instance_dir" src/main.rs | head -10

Repository: spacedriveapp/spacebot

Length of output: 740


🏁 Script executed:

# Let me look at the full Sandbox::new() calls to see all parameters including data_dir
rg -B 2 -A 10 "Sandbox::new" src/main.rs

Repository: spacedriveapp/spacebot

Length of output: 453


🏁 Script executed:

# Also check the second call in api/agents.rs
rg -B 2 -A 10 "Sandbox::new" src/api/agents.rs

Repository: spacedriveapp/spacebot

Length of output: 400


🏁 Script executed:

# Now let me check the agent config to see how workspace and data_dir are configured
rg -B 5 -A 5 "pub struct.*AgentConfig" src/

Repository: spacedriveapp/spacebot

Length of output: 1027


🏁 Script executed:

# Also check what the default or typical paths are
rg -i "workspace|data_dir" src/config.rs | head -20

Repository: spacedriveapp/spacebot

Length of output: 1089


🏁 Script executed:

# Let me verify the exact path structure by examining the config resolution
sed -n '1,150p' src/config.rs | grep -A 30 "fn resolve"

Repository: spacedriveapp/spacebot

Length of output: 48


🏁 Script executed:

# Check how agent_root is constructed
rg -B 5 -A 10 "agent_root" src/config.rs | head -50

Repository: spacedriveapp/spacebot

Length of output: 1158


🏁 Script executed:

# Perfect! Let me verify the exact path relationships
# Default: agent_root = instance_dir/agents/{id}
#         workspace = agent_root/workspace
#         data_dir = agent_root/data
# So they're siblings by default, but the code accounts for custom workspace paths
# Let me check if writable_paths or custom workspace could make data_dir fall under workspace

rg -B 3 -A 3 "writable_paths" src/config.rs

Repository: spacedriveapp/spacebot

Length of output: 48


🏁 Script executed:

# Let me verify the proposed fix placement is syntactically and logically correct
# by checking the exact lines in the current file
sed -n '275,310p' src/sandbox.rs | cat -n

Repository: spacedriveapp/spacebot

Length of output: 1473


SBPL rule ordering: data_dir deny must come before workspace allow.

In Apple's SBPL, rules are evaluated top-to-bottom and the first matching rule decides the outcome—evaluation stops immediately upon match. Since (allow file-write* (subpath "workspace")) appears before (deny file-write* (subpath "data_dir")), any write to data_dir that is a subpath of workspace will match the allow first, causing the deny to be unreached. This violates the stated intent in the code comment: "Protect data_dir even if it falls under the workspace subtree."

Move the data_dir deny above the workspace allow:

Proposed fix
+        // Protect data_dir even if it falls under the workspace subtree
+        // (must come before the workspace allow — SBPL is first-match)
+        let data_dir = canonicalize_or_self(&self.data_dir);
+        profile.push_str(&format!(
+            "; data dir read-only\n(deny file-write* (subpath \"{}\"))\n\n",
+            data_dir.display()
+        ));
+
         // Workspace writable
         profile.push_str(&format!(
             "; workspace writable\n(allow file-write* (subpath \"{}\"))\n\n",
             workspace.display()
         ));

         // Additional writable paths
         for (index, writable) in self.writable_paths.iter().enumerate() {
             let canonical = canonicalize_or_self(writable);
             profile.push_str(&format!(
                 "; writable path {index}\n(allow file-write* (subpath \"{}\"))\n",
                 canonical.display()
             ));
         }

-        // Protect data_dir even if it falls under the workspace subtree
-        let data_dir = canonicalize_or_self(&self.data_dir);
-        profile.push_str(&format!(
-            "\n; data dir read-only\n(deny file-write* (subpath \"{}\"))\n",
-            data_dir.display()
-        ));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Workspace writable
profile.push_str(&format!(
"; workspace writable\n(allow file-write* (subpath \"{}\"))\n\n",
workspace.display()
));
// Additional writable paths
for (index, writable) in self.writable_paths.iter().enumerate() {
let canonical = canonicalize_or_self(writable);
profile.push_str(&format!(
"; writable path {index}\n(allow file-write* (subpath \"{}\"))\n",
canonical.display()
));
}
// Protect data_dir even if it falls under the workspace subtree
let data_dir = canonicalize_or_self(&self.data_dir);
profile.push_str(&format!(
"\n; data dir read-only\n(deny file-write* (subpath \"{}\"))\n",
data_dir.display()
));
// Protect data_dir even if it falls under the workspace subtree
// (must come before the workspace allow — SBPL is first-match)
let data_dir = canonicalize_or_self(&self.data_dir);
profile.push_str(&format!(
"; data dir read-only\n(deny file-write* (subpath \"{}\"))\n\n",
data_dir.display()
));
// Workspace writable
profile.push_str(&format!(
"; workspace writable\n(allow file-write* (subpath \"{}\"))\n\n",
workspace.display()
));
// Additional writable paths
for (index, writable) in self.writable_paths.iter().enumerate() {
let canonical = canonicalize_or_self(writable);
profile.push_str(&format!(
"; writable path {index}\n(allow file-write* (subpath \"{}\"))\n",
canonical.display()
));
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sandbox.rs` around lines 280 - 301, The SBPL deny for data_dir is placed
after the workspace allow so the allow can match first and override the deny;
move the data_dir block that builds the deny rule (the code using
canonicalize_or_self(&self.data_dir) and the profile.push_str call that writes
"(deny file-write* (subpath ...))") to before the workspace writable block (the
section that writes the workspace allow and the loop over self.writable_paths),
ensuring the data_dir deny is emitted before any "(allow file-write*)" rules so
it takes precedence.

@jamiepine jamiepine merged commit af095f3 into main Feb 24, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant